Skip to content

[Performance] HBG: up to 99% host-side overhead reduction - #1659

Open
SergioMartin86 wants to merge 1 commit into
hw-native-sys:mainfrom
huawei-csl:hbg-sm-init-on-write
Open

[Performance] HBG: up to 99% host-side overhead reduction#1659
SergioMartin86 wants to merge 1 commit into
hw-native-sys:mainfrom
huawei-csl:hbg-sm-init-on-write

Conversation

@SergioMartin86

@SergioMartin86 SergioMartin86 commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Human Summary

The host side of HBG was taking 20x more time than the device side. This was due to an unnecessary resetting (zeroing) and copying of the entire scheduling workspace. This PR reduces the H2D transfer to the minimal required information, and the work structures are initialized-on-use, rather than pre-zeroed.

  • Up to 99% (depending on the case) of the host-side costs are reduced.
  • This optimization is orthogonal to that of Add Graph Execution to host_build_graph #1444, both will help independently in ameliorating the host-side overhead.

AI Summary

PR: host_build_graph — init-on-write SM + runtime arena, bounded to total_tasks

Branch hbg-sm-init-on-write off upstream/main (b7141748). One commit, +121/−34
across 6 files.

TL;DR

HBG's per-dispatch wall is 96–99.6% host bind, and bind was dominated by
rebuilding and H2D-uploading full, ring-sized host structures every run — the
shared-memory mirror and the ~20 MB prebuilt runtime arena — even though a run touches
a tiny fraction of either. Their sizes track ring capacity (task window, 65536-slot pools),
not the workload. The device boots scheduler-only and reads no slot past total_tasks, so
both are made init-on-write and shipped bounded to the task count.

Result — host bind, A/B vs upstream (a2a3, exclusive card, 100-round median):

workload baseline this PR Δ
bgemm 77.0 ms 5.1 ms −93%
matmul 80.7 ms 2.1 ms −97%
vector 84.8 ms 3.0 ms −96%
paged_attention 402.7 ms 2.7 ms −99%

Device time is unchanged (~0.05–0.1 ms throughout) — the entire win drops into
per-dispatch latency. bind has ~±1 ms run-to-run jitter, but against 77–403 ms baselines
the reductions are unambiguous.

Problem

Splitting bind (via [STRACE] markers) put nearly all of it in run_host_orchestration /
bind_callable_to_runtime_impl, in two structures that are both sized by ring capacity and
rebuilt+reuploaded every run:

  • The shared-memory mirror (host_sm_buf(sm_size, 0)): 81 MB (default ring) to 651 MB
    (4 GB ring), of which ~97% is the payload segment.
  • The prebuilt runtime arena (~20 MB, fixed): scheduler ready queues (65536×7) +
    tensormap (65536 entries).

A run uses a few dozen of the 16k–131k slots, so almost all of the alloc/zero/init and the
uploads is work on capacity that is never touched.

Fix

The device reads no slot past total_tasks. So:

Shared memory — descriptors and payloads are written per task at submit; slot_states and
completion_flags are reset per slot in orch::prepare_task as it is claimed (dropping the
window-wide reset loop in init_header_per_ring); only the header is zeroed on the host;
each segment is H2D-uploaded bounded to [0, total_tasks).

Runtime arena

  • Don't upload the orchestrator block (fanin_seen_epoch / scope / tensormap, ~8.5 MB): it is
    host-only dep-computation scratch the AICPU scheduler never reads.
  • Skip the build's O(capacity) ready-queue slot init (headers only) and seed each big queue's
    slots post-orchestration, uploading only that live prefix instead of the ring-sized tail.
    Safe: relocate_host_orch_image does not walk the ready queues (roots ride in the SM; the
    device boot scan classifies), so uninitialized slots are never read in between.
  • Prefix length is min(total_tasks + 1, capacity). A queue takes at most total_tasks
    pushes, but PTO2ReadyQueue::pop_batch_tagged reads one slot past dequeue_pos — the slot
    at enqueue_pos — to detect the empty boundary; a batched dequeue that finds a stale
    (too-large) Vyukov sequence there spins forever. So the prefix seeds one sentinel slot past
    the task count, giving the boundary read a valid "empty" sequence; the tail beyond it is
    never touched. (Capacity is a fixed 65536 while the task window can exceed it, so the prefix
    is clamped.)
  • Drop the redundant per-entry stores in the tensormap reset (the preceding memset already
    zeroes the link pointers and producer_task_id).

total_tasks is range-checked before it sizes the copies. Single-ring (PTO2_MAX_RING_DEPTH == 1).

The +1 sentinel slot — why bounding a Vyukov queue to exactly total_tasks deadlocks

This is the non-obvious footgun in bounding the ready queues, worth calling out for anyone
who touches this code or applies the same "ship a live prefix" pattern to another MPMC queue.

A first cut seeded and shipped each ready queue's slots [0, total_tasks) — the reasoning
being "a queue takes at most total_tasks pushes, and reads no slot past its push count." The
first half is true; the second is not. PTO2ReadyQueue::pop_batch_tagged sizes a batch by
scanning forward from dequeue_pos until it hits a slot whose Vyukov sequence marks it empty
— and that terminating read lands on the slot at enqueue_pos, i.e. one slot past the last
one actually pushed
. When a queue holds every task, enqueue_pos reaches total_tasks, so
the boundary read touches slot total_tasks — the first slot the [0, total_tasks) prefix did
not seed or upload.

On a fresh device that slot happens to read as empty and nothing breaks — which is why this
passed single-shot runs. On reuse of the persistent device arena (2nd+ invocation of any
callable), that slot still holds a stale, too-large sequence from a prior run. pop_batch
reads it, computes diff > 0, and — per its contract — treats that as "a concurrent producer
is mid-push here, retry" and spins the outer loop forever. The last ready task is never
dequeued; the scheduler latches SCHEDULER_TIMEOUT (surfaced host-side as 507018). It
reproduced intermittently under pytest-xdist (~2 hangs/pass) and was invisible to golden runs
that don't reuse across differently-sized callables.

Fix: seed and ship the prefix as min(total_tasks + 1, capacity) — one sentinel slot past
the task count, carrying its normal empty sequence so the boundary read resolves to "empty" and
pop_batch breaks cleanly. pop_batch never scans past that first empty slot, and per-queue
enqueue_pos ≤ total_tasks, so a single sentinel is sufficient for every queue and every split
of tasks across shapes. The tail beyond it is genuinely never read.

Takeaway for future bounding work: a lock-free queue's read set can extend one element past
its write set. Bound to the read set (pushes + 1), not the write set (pushes).

Why it is safe

Every scheduler-read field is explicitly initialized at submit, so nothing depends on the
removed blanket zeros; reads are bounded by current_task_index. The orchestrator block is
host-only (verified: zero AICPU-scheduler references); relocation never walks the ready
queues; the tensormap-reset change is a pure dedup of what memset already wrote.

Validation

100-round golden × 4 workloads PASS (bgemm, matmul, vector, paged_attention — the last with
its 131072-slot window, exercising the capacity clamp and cross-run queue reuse).

Cross-run reuse is stress-tested under pytest-xdist (one worker per device, the whole HBG
suite, multiple back-to-back passes across 6 devices): 0 scheduler stalls / 0 op-execute
timeouts. This is the regression barrier for the pop_batch boundary slot — before the
total_tasks + 1 sentinel, the same stress produced an intermittent SCHEDULER_TIMEOUT
(≈2 hangs/pass) on the second-and-later invocation of a callable that reused the persistent
device arena.

Full HBG a2a3 scene-test suite green except one failure —
run_stream_reuse::test_depth_two_slots_own_separate_resources (slot-1 GM heap bank
uncommitted) — which reproduces identically on clean upstream/main, i.e. pre-existing and
unrelated (arena-bank path, not this change).

Scope / not done

  • a2a3 host_build_graph only. Orthogonal to Add Graph Execution to host_build_graph #1444 (graph execution): that shrinks what goes
    into the SM; this fixes the SM/arena buffer lifecycle. They compound.
  • Fully bounding the tensormap reset (a further ~1–2 ms) is deliberately left out:
    print_stats scans the whole entry pool and dereferences entries, and the pool has a
    recycling free-list, so bounding its reset is high-risk for a sub-noise gain. The 8 MB
    memset stays.

@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The host runtime now uses uniquely owned shared-memory storage, initializes only control regions, and uploads the live payload prefix plus complete control segments to the device.

Changes

Shared-memory staging

Layer / File(s) Summary
Selective initialization and device upload
src/a2a3/runtime/host_build_graph/host/runtime_maker.cpp
The runtime uses std::unique_ptr storage, selectively clears control regions, and splits device uploads between the live payload prefix and complete control segments.

Estimated code review effort: 2 (Simple) | ~10 minutes

Possibly related PRs

Poem

A rabbit hops through memory bright,
Leaves payload bytes untouched in flight.
Control flags clear, the slots align,
Two careful transfers cross the line.
“Efficient staging!” thumps my cheer.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: reducing HBG host-side overhead through a performance optimization.
Description check ✅ Passed The description explains the host-side overhead problem, the scheduling workspace optimization, measured results, scope, and validation.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/a2a3/runtime/host_build_graph/host/runtime_maker.cpp`:
- Around line 537-548: Validate total_tasks before computing payload_prefix_end
or performing relocation/copy operations: require it to be non-negative and no
greater than eff_task_window_sizes[0]. Reject invalid values early, preserving
the existing copy behavior only for valid task counts.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e9f8a9e0-e5d7-420b-a483-e1085fb0681e

📥 Commits

Reviewing files that changed from the base of the PR and between 71433ca and 87874ee.

📒 Files selected for processing (1)
  • src/a2a3/runtime/host_build_graph/host/runtime_maker.cpp

Comment thread src/a2a3/runtime/host_build_graph/host/runtime_maker.cpp Outdated
@SergioMartin86
SergioMartin86 force-pushed the hbg-sm-init-on-write branch 3 times, most recently from 184c2cd to ed90f0c Compare August 4, 2026 08:08
@SergioMartin86 SergioMartin86 changed the title [Performance] HBG: 80~95% host-side overhead reduction [Performance] HBG: 84~98% host-side overhead reduction Aug 4, 2026
@SergioMartin86
SergioMartin86 force-pushed the hbg-sm-init-on-write branch 3 times, most recently from 677289d to ab534b5 Compare August 4, 2026 09:52
@SergioMartin86 SergioMartin86 changed the title [Performance] HBG: 84~98% host-side overhead reduction [Performance] HBG: up to 99% host-side overhead reduction Aug 4, 2026
…tasks

HBG's per-dispatch wall is 96-99.6% host bind, and bind was dominated by
rebuilding and H2D-uploading full, ring-sized host structures every run -- the
shared-memory mirror and the ~20 MB prebuilt runtime arena -- even though a run
touches a tiny fraction. Their sizes track ring capacity (task window, 65536-slot
pools), not the workload. The device boots scheduler-only and reads no slot past
total_tasks, so both are made init-on-write and shipped bounded to the task count.

Shared memory:
- descriptors and payloads are written per task at submit; slot_states and
  completion_flags are reset per slot in orch::prepare_task as it is claimed,
  dropping the window-wide reset loop in init_header_per_ring; only the header is
  zeroed on the host; each segment is H2D-uploaded bounded to [0, total_tasks).

Runtime arena:
- skip uploading the orchestrator block (fanin_seen_epoch / scope / tensormap,
  ~8.5 MB): host-only dep-computation scratch the AICPU scheduler never reads.
- skip the build's O(capacity) ready-queue slot init (headers only) and seed each
  big queue's slots post-orchestration, uploading only that live prefix; safe
  because relocate_host_orch_image does not walk the ready queues (roots ride in
  the SM; the device boot scan classifies), so uninitialized slots are never read
  between.
- the prefix is min(total_tasks + 1, capacity). A queue takes at most total_tasks
  pushes, so its enqueue_pos never exceeds total_tasks, but pop_batch_tagged reads
  one slot past dequeue_pos -- the slot at enqueue_pos -- to detect the empty
  boundary, and a batched dequeue that finds a stale (too-large) Vyukov sequence
  there spins forever. Seeding one sentinel slot past the task count gives that
  boundary read a valid empty sequence; the ring-sized tail beyond it is never
  touched.
- drop the redundant per-entry stores in the tensormap reset (the preceding memset
  already zeroes the link pointers and producer_task_id).

Every scheduler-read field is initialized at submit, so nothing depends on the
removed blanket zeros; reads are bounded by current_task_index. total_tasks is
range-checked before it sizes the copies. Single-ring (PTO2_MAX_RING_DEPTH == 1).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MqeALZTPEnDXTnbYfcnPfq
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant